/**
 * forked from {@link https://github.com/Donny2333/koa2-sso/blob/dev/index.js}
 */

import axios from 'axios';
import type * as Koa from 'koa';
import Xml from 'xml2js';
import XMLprocessors from 'xml2js/lib/processors';
import crypto from 'node:crypto';
import _ from 'lodash';

import Logger from '../../utils/Logger';
import config from '../../config';
import HttpHelper from '../../utils/HttpHelper';

export interface CasClientOptions {
  serverUrl: string;
  serviceUrl: string;
  loginSuccessReturnToUrl?: string,
  renew?: boolean;
  sessionNameKey?: string;
  sessionInfoKey?: string;
  willDestroySessionAfterLogout?: boolean;
  validateApiPath?: string,
  serverApiPaths?: {
    login?: string,
    logout?: string,
    serviceValidate?: string,
  },
  developMode?: {
    isEnabled?: boolean,
    user?: string,
    info?: Record<string, any>,
  };
}

export default class CtCasClient {
  private serverUrl!: string;
  private serviceUrl!: string;
  private loginSuccessReturnToUrl?: string;
  private sessionNameKey = 'cas_user';
  private sessionInfoKey?: string;
  private willDestroySessionAfterLogout?: boolean;
  private serverApiPaths = {
    login: '/cas/login',
    logout: '/cas/logout',
    validate: '/cas/serviceValidate',
  };

  private developMode?: {
    isEnabled?: boolean,
    user?: string,
    info?: Record<string, any>,
  };

  constructor(options: CasClientOptions) {
    _.merge(this, options);
  }

  public tryToBlock = async (context: Koa.Context, next: Koa.Next) => {
    if (!context.session) {
      throw new Error('The operation of the CAS client depends on the session service, but we did not find any session service in the Koa context.');
    }
    if (context.session[this.sessionNameKey]) {
      await next();
      return;
    }
    if (this.developMode?.isEnabled && context.session) {
      context.session[this.sessionNameKey] = this.developMode?.user;
      if (this.sessionInfoKey) {
        context.session[this.sessionInfoKey] = this.developMode?.info;
      }
      await next();
      return;
    }
    // 在需要重新登录时，不返回 302，而是返回 401 和 /cas/login 链接，让前端主动前往该链接，以避免多次 302 导致的跨域问题
    // https://gitlab.ctyun.cn/zhanglm8/cloud-native-security-frontend/-/issues/3#note_100923
    context.status = 401;
    context.body = { redirectTo: this.getRedirectToLoginUrl() };
  };

  public queryTicket = async (context: Koa.Context) => {
    const validateParams = this.getValidateParams(context);
    try {
      const validateResponse = await axios.get(
        `${this.serverUrl}${this.serverApiPaths.validate}`,
        { params: validateParams },
      );
      const authSuccess = await CtCasClient.validateResponse(validateResponse.data);
      if (!context.session) {
        throw new Error('The operation of the CAS client depends on the session service, but we did not find any session service in the Koa context.');
      }
      context.session[this.sessionNameKey] = authSuccess.user;
      if (this.sessionInfoKey) {
        context.session[this.sessionInfoKey] = authSuccess.attributes || {};
      }
      context.redirect(this.loginSuccessReturnToUrl || this.serviceUrl);
    } catch (error: any) {
      Logger.warn('CAS request error', error);
      context.status = 503;
      context.body = { casRequest: error.config?.url, errorMessage: error.message || 'CAS request error' };
    }
  };

  public logout = async (context: Koa.Context) => {
    if (!context.session) {
      throw new Error('The operation of the CAS client depends on the session service, but we did not find any session service in the Koa context.');
    }
    if (this.willDestroySessionAfterLogout) {
      context.session = null;
    } else {
      delete context.session[this.sessionNameKey];
      if (this.sessionInfoKey) {
        delete context.session[this.sessionInfoKey];
      }
    }
    context.status = 202;
    context.body = { redirectTo: `${this.serverUrl}${this.serverApiPaths.logout}` };
  };

  public getRedirectToLoginUrl = () => {
    const url = new URL(this.serverApiPaths.login, this.serverUrl);
    url.searchParams.append('service', this.serviceUrl);
    return url.toString();
  };

  private static validateResponse = async (responseBody: string) => {
    const xmlParser = new Xml.Parser({
      trim: true,
      normalize: true,
      explicitArray: false,
      tagNameProcessors: [
        XMLprocessors.normalize,
        XMLprocessors.stripPrefix,
      ],
    });
    try {
      const parseResult = await xmlParser.parseStringPromise(responseBody);
      const authFailure = parseResult.serviceresponse.authenticationfailure;
      if (authFailure) {
        throw new Error(`CAS authentication failed ('${authFailure.$.code}').`);
      }
      const authSuccess = parseResult.serviceresponse.authenticationsuccess;
      if (authSuccess) {
        return authSuccess;
      }
    } catch (error: any) {
      throw new Error('Response from CAS server was bad.', { cause: error });
    }
    throw new Error('CAS authentication failed.');
  };

  private getValidateParams = (context: Koa.Context) => {
    const ticket = HttpHelper.filedMixedToSingular(context.query?.ticket);
    if (!ticket) {
      throw new Error('CAS ticket is empty, build validate params failed.');
    }
    const { appId, appSecret } = config.cas;
    if (!appId || !appSecret) {
      // 若没有注册 appId 或 appSecret，则跳过参数签名
      return { service: this.serviceUrl, ticket };
    }
    const timestamp = new Date().getTime();
    const decodedAppSecret = Buffer.from(appSecret, 'base64url');
    const hmac = crypto.createHmac('sha256', decodedAppSecret);
    hmac.update(`${appId}@${this.serviceUrl}@${ticket}@${timestamp}`);
    const signature = hmac.digest('base64url');
    return {
      service: this.serviceUrl,
      ticket,
      timestamp,
      appId: config.cas.appId,
      signature,
    };
  };
}
